Skip to content

ref(time): Deprecate the legacy date and clock providers (JAVA-571) - #6043

Draft
runningcode wants to merge 28 commits into
no/java-579-anr-uptime-clockfrom
no/java-571-deprecate-date-providers
Draft

ref(time): Deprecate the legacy date and clock providers (JAVA-571)#6043
runningcode wants to merge 28 commits into
no/java-579-anr-uptime-clockfrom
no/java-571-deprecate-date-providers

Conversation

@runningcode

@runningcode runningcode commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

PR Stack (Clock semantics hardening)


📜 Description

Marks the legacy date and clock surface as deprecated, now that the internal consumers that could
move have moved (#6029, #6030, #6032, #6041). Nothing is migrated here and nothing is removed — the
point is to start the warning cycle before the next major, so users and the 9.x.x branch get a
full release of notice.

Deprecated Replacement
ICurrentDateProvider.getCurrentTimeMillis() MonotonicClock for an interval, SentryDateProvider for a timestamp
CurrentDateProvider.getInstance() SentryDateProvider for a timestamp, MonotonicClock for an interval
AndroidCurrentDateProvider.getInstance() MonotonicClock (this one was SystemClock.uptimeMillis() all along)
DateUtils.getCurrentDateTime() options.getDateProvider().now()
AndroidDateUtils.getCurrentSentryDateTime() options.getDateProvider()

The DateUtils class is deliberately not deprecated: only getCurrentDateTime() reads a clock,
while its other fourteen statics are pure conversion and formatting helpers used all over the SDK.

Two deviations from the plan, both forced

1. The annotations sit on members, not on the types. The plan called for deprecating
ICurrentDateProvider, CurrentDateProvider and AndroidCurrentDateProvider as types. That cannot
compile here: the Android modules build at Java 8, where javac still emits a deprecation warning for
an import of a deprecated type (JEP 211 elides those only from source 9 on), -Xlint:all -Werror
turns it into an error, and an import declaration cannot carry a @SuppressWarnings — a class-level
suppression does not cover it either. I verified both halves of that empirically. Thirteen Java files
in sentry-android-core import these types; the alternative was writing
io.sentry.transport.ICurrentDateProvider inline at ~40 use sites until the next major.

Deprecating getCurrentTimeMillis() and getInstance() warns any caller just as loudly, and every
warning it produces lands somewhere a suppression can go. The type-level javadoc still names the
replacement, so IDEs and Javadoc readers see it.

2. Kotlin call sites are left warning, not suppressed. -Werror applies to JavaCompile only,
so the 17 warnings in sentry-android-replay and sentry-okhttp do not break anything. They are the
live migration list for JAVA-575; suppressing them would trade a checklist for a comment. That module
already carries other deprecation warnings, so this is not a new kind of noise. Say the word if you'd
rather have them suppressed.

💡 Motivation and Context

ICurrentDateProvider is the defect JAVA-571 was filed about. One interface carried two
incompatible clocks — CurrentDateProvider is System.currentTimeMillis(), AndroidCurrentDateProvider
is SystemClock.uptimeMillis() — and nothing in the name or the type said which. A field declared
ICurrentDateProvider accepts either, and the two disagree by however long the device has been
suspended.

Each of the 21 Java suppressions carries a // TODO [MAJOR] naming the replacement that site should
take, because a suppressed warning stops being a checklist entry. Nearly all of them are frozen until
the next major because they produce serialized timestamps — SentryEvent, Breadcrumb, Session,
SentryReplayEvent, ProfilingTraceData, both profilers, the activity-lifecycle span helpers. Only
ClientReportRecorder and EnvelopeCache are internal. That is expected: this PR marks the surface,
it does not migrate it.

Four sites must stay on the wall clock and say so: AnrV2Integration, TombstoneIntegration and
ApplicationExitInfoHistoryDispatcher compare against epoch ApplicationExitInfo timestamps, and
LifecycleWatcher against Session.getStarted().

  • resolves: JAVA-571

💚 How did you test it?

No behaviour changes, so no new tests — this is annotations plus suppressions.

./gradlew :sentry:test :sentry:apiCheck :sentry-android-core:testReleaseUnitTest :sentry-android-core:apiCheck :sentry-android-replay:testReleaseUnitTest :sentry-okhttp:test :sentry-apache-http-client-5:test — green.

./gradlew spotlessApply apiDump produces no .api diff: BCV does not record annotations.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

🔮 Next steps

The removals, and the serialized-value migrations behind every // TODO [MAJOR] here, belong to the
9.x.x branch: JAVA-572 (span durations), JAVA-575 (replay timings), JAVA-577 (session durations),
JAVA-578 (profiler re-anchoring), JAVA-642 (app-start spans).

⚠️ Merge this PR using a merge commit (not squash), so the rest of the stack keeps a clean history.

@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

JAVA-571

@sentry

sentry Bot commented Sep 2, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.55.0 (1) release

⚙️ sentry-android Build Distribution Settings

@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from cabb812 to 77eb8c9 Compare September 2, 2026 13:35
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch 2 times, most recently from 4f8f1ff to d35ab7c Compare September 2, 2026 13:37
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from 77eb8c9 to 2065803 Compare September 2, 2026 13:37
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from 2065803 to 498932f Compare September 3, 2026 15:11
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from d35ab7c to f375021 Compare September 3, 2026 15:11
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from 498932f to ae9d046 Compare September 3, 2026 15:41
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from f375021 to 86b6e5e Compare September 3, 2026 15:41
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from ae9d046 to 46f3c83 Compare September 4, 2026 08:58
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from 86b6e5e to 727a95b Compare September 4, 2026 08:58
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from 46f3c83 to 2c9adb8 Compare September 4, 2026 15:11
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from 727a95b to edcf5cd Compare September 4, 2026 15:11
runningcode and others added 9 commits September 8, 2026 14:59
…-571)

`ICurrentDateProvider.getCurrentTimeMillis()` has two implementations that
mean different things: `CurrentDateProvider` returns wall time, while
`AndroidCurrentDateProvider` returns `SystemClock.uptimeMillis()`, which is
monotonic and pauses in deep sleep. Every consumer has to hand-pick the one
matching whatever it compares against, a wrong pairing compiles silently, and
the tests inject fakes so nothing catches it.

Name the guarantee instead. UptimeClock excludes time the device spent
suspended and is what ANR detection needs, since counting suspended time
reports a responsive main thread as blocked. ElapsedRealtimeClock includes it
and is what a rate-limit window or a cache TTL needs. A call site declaring
which one it wants can no longer be handed the other.

Both extend Ticker, which promises only "a nanosecond counter with an
arbitrary origin" so that Deadline and Stopwatch can be written once. That
minimalism is deliberate: a name promising a guarantee it does not keep is
the bug being fixed here.

Deadline and Stopwatch exist so callers never do arithmetic on raw ticks. A
tick carries no unit and no epoch, so `now - then < ttl` spelled out at each
call site is where unit mix-ups, sentinels that happen to mean "boot", and
wrap-unsafe comparisons come from. Deadline.passed() gives "not populated
yet" a representation outside the numeric range, hasPassed() subtracts rather
than compares so it holds for any origin, and remaining() rounds up so a
caller scheduling work for it never wakes to find the deadline still standing.

No call site is converted and no behaviour changes. Only the elapsed-real-time
clock will need an Android implementation: `SystemClock.uptimeNanos()` is API
34 against a minSdk of 21, and `System.nanoTime()` is already CLOCK_MONOTONIC
on Android, so it serves as the uptime clock on both platforms.
System.nanoTime() is CLOCK_MONOTONIC on Android too, and
SystemClock.uptimeNanos() is API 34 against minSdk 21, so there is no
platform-specific uptime implementation to install. The setter had no
production caller and its only test was a test of itself, while still
occupying binary-compatibility surface in sentry.api.

ElapsedRealtimeClock keeps its seam: RateLimiter lives in the core
module but needs SystemClock.elapsedRealtimeNanos() on Android, which
only sentry-android-core can supply.

UptimeClock and JavaUptimeClock remain; call sites that want the
guarantee named in their type resolve the singleton directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The elapsed-real-time field carried a comment repeating its own type,
and both the setter and JavaElapsedRealtimeClock restated what the
ElapsedRealtimeClock javadoc already says at length.

The setter javadoc now answers the question a reader actually has when
they find a setter on an internal option: which platform installs one,
and why the core module cannot construct it itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…A-571)

Installs AndroidElapsedRealtimeClock in AndroidOptionsInitializer, beside
the existing SentryAndroidDateProvider. Nothing reads the clock yet, so
this changes no behaviour.

Without it the options seam added in this PR is inert on Android: the
default resolves to System.nanoTime(), which is CLOCK_MONOTONIC and stops
in deep sleep, so a reviewer sees a setter with no caller and Android
silently gets the guarantee the type says it does not provide. That was
the flaw in the previous attempt at this abstraction, where the Android
clock was built into a local and never installed.

io.sentry.android.core.internal is in apiValidation.ignoredPackages, so
there is no .api diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SentryOptionsClockTest asserted that a LazyEvaluator-backed getter
returns what its setter was given; AndroidOptionsInitializerTest already
covers the setter for real, on the one caller that uses it.
JavaClocksTest asserted singleton identity and that a nanosecond counter
does not run backwards.

Neither can fail without the language failing first. DeadlineTest and
StopwatchTest, which cover the arithmetic this package exists to
centralise, are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SentryAndroidOptions now returns AndroidElapsedRealtimeClock from an
override, so SentryOptions no longer needs a setter and the core default
collapses to the singleton it always returned.

Three things get better. The setter was a mutation point on an option
nobody should swap, and it is gone from sentry.api. Android is correct
from construction rather than from the moment AndroidOptionsInitializer
runs, closing the window where a reader saw System.nanoTime(). And
consumers that take a clock in their constructor, as RateLimiter will,
keep their own injection point for tests, so nothing lost a seam.

The cost is that this is the only getter SentryAndroidOptions overrides;
every other platform swap is installed in AndroidOptionsInitializer.
Those are user-replaceable options, though, and this one is internal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It took a public constructor to match `new SentryAndroidDateProvider()`
on the line beside it in AndroidOptionsInitializer. That line is gone
now that SentryAndroidOptions overrides the getter, so the odd one out
was the clock rather than the neighbour.

With getInstance() it matches the two JVM clocks, and the field it was
stored in disappears: both overrides are now the same single line
returning a singleton.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`in` is a Kotlin hard keyword, so every Kotlin call site had to spell it
`Deadline.`in`(...)`. Tests in this repo are Kotlin, and Kotlin callers
are expected in the Android and Kotlin integration modules, so the
backticks would have spread rather than stayed in one test file.

`after` is a plain identifier in both languages and pairs with the
existing `hasPassed()` and `isAfter()` vocabulary.
The Deadline.in to Deadline.after rename did not regenerate
sentry/api/sentry.api, so :sentry:apiCheck failed on CI.
Two interfaces existed so that a call site could name which suspend
behaviour it needed, but only ANR detection wanted the one that excludes
deep sleep, and neither ANR path can be fooled by a suspend: ANRWatchDog
reports only once ActivityManager confirms NOT_RESPONDING, and
AnrProfilingIntegration parks its thread while backgrounded and resets
its baseline on wake.

That leaves one guarantee worth naming. Ticker, UptimeClock and
JavaUptimeClock are gone, and ElapsedRealtimeClock becomes
MonotonicClock, backed by elapsedRealtimeNanos() on Android and
nanoTime() on the JVM.
The type promises less than "clock" suggests: a tick carries no unit and
no epoch, and only differences between two ticks from the same instance
mean anything. "Ticker" keeps it from reading like a source of wall-clock
time.

Deadline.after now rejects a negative amount, since a deadline that
starts out in the past is a sign error at the call site and passed()
already expresses that case deliberately.

Also drops the "state that has not been populated yet" framing from the
Deadline.passed javadoc, covers isAfter on two equal deadlines, and
rewrites the tick-zero test comment to name the last-updated-timestamp
pattern it is about, rather than leaving "sentinel of 0" to be read as a
deadline of 0.
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from 2c9adb8 to d570570 Compare September 8, 2026 13:32
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from edcf5cd to dfe0f15 Compare September 8, 2026 13:33
runningcode and others added 11 commits September 8, 2026 17:07
SentryDate is asked to be four things at once: an epoch instant to
serialize, one endpoint of a monotonic interval, a carrier of a hidden
System.nanoTime() reading, and an opaque foreign timestamp. Nothing in the
type separates them, so the guarantees are decided by the runtime class of
both operands -- SentryNanotimeDate.diff() is monotonic only when the other
date is also a SentryNanotimeDate, and silently subtracts two wall-clock
readings otherwise. On the JVM, where SentryAutoDateProvider picks
SentryInstantDate, neither endpoint has a monotonic component and span
durations are not monotonic at all.

The fix is not to type the instants more carefully. It is to stop producing
them independently. A group of instants that will be compared against each
other -- the spans of a transaction, the samples of a profile chunk, the
segments of a replay -- reads the epoch once and projects the rest through
the monotonic clock:

  Timestamp      an epoch instant, plus the anchor that projected it, or
                 null when it was read or stated directly. No arithmetic
                 between instants; equality is by instant.
  EpochClock     the wall clock, for stamping a moment that leaves the
                 process. Deliberately cannot report a duration.
  AnchoredClock  one epoch reading pinned to one tick. now() and at(tick)
                 project, tickOf() inverts exactly, driftNanos() reports how
                 far the projection has fallen behind the wall clock.

Subtracting two instants from one anchor is subtracting two ticks, so a
duration is monotonic by construction rather than by convention, and a clock
step cannot make a child span start before its parent. It also gives Android
nanosecond resolution it cannot read directly, the epoch being
millisecond-granular there -- the workaround SentryNanotimeDate describes,
applied once per group instead of between each pair of readings.
OpenTelemetry's SDK anchors per local root span for the same two reasons.

tickOf() refusing an instant it did not project is what makes this safer
rather than merely tidier: mixing domains becomes an exception instead of a
plausible-looking wrong number, the same guard Deadline.isAfter applies to
clocks.

Timing is dropped rather than kept. It paired one Timestamp with one
Stopwatch, which is what AnchoredClock does for a whole group, and no call
site would have wanted the single-interval version.

Nothing calls any of it yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`When network is active but not connected with permission, return
DISCONNECTED` mocked an active network reporting isConnected=false
alongside NetworkCapabilities describing a validated WiFi link. Those
describe opposite worlds.

It passes today only because the empty connection cache reads as fresh
for the first two minutes of every boot (JAVA-717), which forces the
legacy activeNetworkInfo path where the capability mocks are never
consulted. buildInfo reports API 24, so once that bug is fixed the
provider reads capabilities and the test would fail for a reason that
has nothing to do with what it is named after.

Fixing the mocks first keeps that failure from being buried in the
commit that fixes the cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lastCacheUpdateTime used 0 for "never populated" while being compared
against SystemClock.uptimeMillis(), which starts at 0 at boot. For the
first two minutes of every boot the empty cache therefore read as fresh,
so getConnectionStatus() skipped updateCache() and fell through to the
legacy activeNetworkInfo path instead of reading NetworkCapabilities.
The window reopens after every unregisterNetworkCallback(), which reset
the field to 0.

Switching clocks does not fix this on its own: elapsedRealtimeNanos()
also starts at 0 at boot. Any 0-means-unset long compared against a
boot-relative clock has the same flaw; only epoch millis made it safe,
because there 0 is 1970.

The cache now holds a Deadline, so "never populated" is expired by
construction and has no numeric value to get wrong. The provider takes
an MonotonicClock in place of ICurrentDateProvider, which is what
a two-minute TTL wants: it must keep counting while the device sleeps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-574)

Retry-after limits were java.util.Date values derived from
System.currentTimeMillis(). A wall clock is the wrong instrument for a
backoff window: it steps when the device syncs time, so an NTP
correction could lift a 60 second rate limit early or extend it by
however far the clock jumped. The limits now live on the monotonic
clock, which counts forward at a steady rate and keeps counting while
the device sleeps, which is what a server-dictated wait means.

Storing Deadline rather than a timestamp also removes the duplicated
parameter on applyRetryAfterOnlyIfLonger, which took both an absolute
deadline and the delay needed to reach it, and lets three JdkObsolete
and JavaUtilDate suppressions go with the Dates.

RateLimiter also took the whole SentryOptions while reading exactly
three methods from it. It now depends on RateLimiterConfig, declared
next to its consumer, so what a rate limiter touches is three lines to
read rather than three hundred. SentryOptions implements it with no new
methods, so every existing caller compiles unchanged.

Both existing constructors stay, so the .api diff is additions only. The
ICurrentDateProvider one is deprecated and adapts the injected provider
rather than ignoring it, since a custom ITransportFactory may be passing
one.

One boundary moves by a nanosecond: a limit used to be active while
`now <= deadline` and is now active while `now < deadline`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four check-in paths kept a `long startTime = System.nanoTime()` and
subtracted it in a finally block: CheckInUtils and the SentryCheckInAdvice
in sentry-spring, sentry-spring-jakarta and sentry-spring-7.

The clock now comes from the options, so a check-in measures on whatever
the SDK measures on: System.nanoTime() on the JVM, unchanged to the bit,
and SystemClock.elapsedRealtimeNanos() on Android. A cron job that spans a
suspend therefore reports the duration a user would measure rather than the
time the CPU happened to be awake, which is what a check-in duration is
meant to mean.

An uninitialised SDK still reaches a clock: NoOpScopes.getOptions() hands
back empty options, whose clock is the JVM one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ANR tests advance the ticker from the test thread while the watchdog
thread reads it, which without volatile is a data race that can leave the
watchdog looking at a stale tick forever.
The watchdog took its readings from an ICurrentDateProvider lambda over
SystemClock.uptimeMillis(). The type named no clock, so a call site could
not tell what it was measuring, and the arithmetic -- now minus the last
tick, compared against a threshold -- was spelled out inline.

MonotonicClock and Deadline replace both: the clock is a named type, and
the watchdog asks the question it actually cares about, which is whether
the main thread has missed its window.

The clock counts deep sleep, which uptimeMillis() did not, so a suspend
between posting the ticker and checking it now looks like a missed
window. It cannot fabricate an ANR: the watchdog reports only once
ActivityManager confirms the process is NOT_RESPONDING, and on resume the
main thread runs the ticker that is already queued.
Same reasoning as the watchdog: the suspicion and ANR thresholds are now
read from a named clock rather than SystemClock, and injecting it lets the
tests drive it directly instead of going through Robolectric's shadow
clock.

Deep sleep cannot inflate the measurement here either, because the polling
thread parks itself while the app is backgrounded and resets the baseline
when it wakes.
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from d570570 to 51e0083 Compare September 8, 2026 15:10
It reads the wall clock into a java.util.Date at millisecond resolution.
SentryDateProvider already owns wall time, is configurable, stubbable in
tests and resolves finer, so there is no reason for new code to reach for
the static.

The class itself is not deprecated: its other fourteen statics are pure
conversion and formatting helpers with no clock in them.

Every caller keeps working and gets a suppression, because -Xlint:all
-Werror turns the warning into a build failure. Each suppression carries a
TODO [MAJOR] naming the replacement, since a suppressed warning is no
longer a checklist entry — and nearly all of these callers are frozen
until the next major anyway, as they stamp serialized timestamps.
One interface carried two incompatible clocks: CurrentDateProvider is
System.currentTimeMillis() and AndroidCurrentDateProvider is
SystemClock.uptimeMillis(). Nothing in the name or the type said which,
which is the defect JAVA-571 is about — a field declared
ICurrentDateProvider accepts either, and the two disagree by however long
the device has been suspended. MonotonicClock names the guarantee it
gives, and SentryDateProvider covers wall time.

The annotation goes on the members rather than the types: the Android
modules compile at Java 8, where javac still warns on imports of a
deprecated type, and an import declaration cannot carry a
@SuppressWarnings. Deprecating getCurrentTimeMillis() and getInstance()
warns any caller just the same, and every warning it produces lands
somewhere that can be suppressed.

Each suppression names the replacement the site should take at the next
major. Several of these must stay on the wall clock: AnrV2Integration,
TombstoneIntegration and ApplicationExitInfoHistoryDispatcher compare
against epoch ApplicationExitInfo timestamps, and LifecycleWatcher against
Session.getStarted().
…VA-571)

Its own javadoc already told callers to prefer options.getDateProvider();
this makes the compiler say so. The static holder cannot be configured or
stubbed, which is the whole reason the note was there.

Annotated on the method rather than the class, for the same import reason
as the current-date providers.
@runningcode
runningcode force-pushed the no/java-571-deprecate-date-providers branch from dfe0f15 to b50dc01 Compare September 8, 2026 15:10
@runningcode
runningcode force-pushed the no/java-579-anr-uptime-clock branch from 51e0083 to a2620aa Compare September 9, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant